Перейти к основному содержимому

5.05. ASP.NET

Разработчику Архитектору

ASP.NET

C# позволяет писать не только десктопные и мультиплатформенные приложения, но и веб-приложения. Для этого направления есть целая технология ASP.NET.

Давайте погрузимся в веб-разработку.


Часть 1. Введение в веб-разработку на платформе .NET

Веб-разработка в экосистеме .NET — это последовательная эволюция архитектурных подходов, обусловленная изменением требований к приложениям: от монолитных, серверных, stateful систем к распределённым, stateless, API-ориентированным сервисам. Эта эволюция отражена в линейке технологий Microsoft: ASP.NET Web Forms → ASP.NET MVC → ASP.NET Web API → ASP.NET Core.

Web Forms (2002) стремился перенести модель событийного программирования Windows Forms в веб — с её PostBack’ами, ViewState и серверными контролами. Это позволяло разработчикам, привыкшим к desktop-разработке, быстро создавать интерактивные веб-страницы, но ценой чёрного ящика: сложная модель состояния, неявная генерация HTML, трудности в тестировании и масштабировании. Архитектура была page-centric: логика жила внутри страницы, а не в переиспользуемых компонентах.

ASP.NET MVC (2009) стал ответом на рост популярности фреймворков вроде Ruby on Rails и Django. Он ввёл явное разделение ответственности по шаблону Model-View-Controller, обеспечил полный контроль над HTML, упростил unit-тестирование и способствовал созданию чистых HTTP-интерфейсов. MVC не заменил Web Forms — он предложил альтернативную модель, ориентированную на разработчиков, мыслящих в терминах HTTP и REST.

Параллельно выросла потребность в интерфейсах для клиентских приложений (SPA, мобильные приложения). ASP.NET Web API (2012) был извлечён из MVC как отдельный стек для построения HTTP-based API — лёгких, сериализуемых, контрактных сервисов, возвращающих JSON или XML. Хотя Web API использовал тот же DI, маршрутизацию и фильтры, что и MVC, его семантика была иной: здесь не было View — только модели, контроллеры (ApiController) и сериализаторы.

Настоящий перелом произошёл с появлением ASP.NET Core (2016). Это не улучшенная версия ASP.NET — это переписанная с нуля платформа, не имеющая прямой зависимости от System.Web.dll, совместимой с .NET Framework. ASP.NET Core — кроссплатформенный, высокопроизводительный, модульный и open-source фреймворк, объединивший MVC, Web API и Razor Pages в единую модель. Он построен на новых принципах:

  • Явная конфигурация вместо магии (нет глобальных статических классов вроде HttpContext.Current),
  • Композиция через middleware, а не наследование и события,
  • Встроенный DI и конфигурация как первоклассные граждане,
  • Контракт хостинга, позволяющий запускать приложение в любом окружении: от IIS до Docker-контейнера на Linux.

Ключевое понимание: в ASP.NET Core нет «веб-приложения» как отдельной сущности. Есть хост, который управляет жизненным циклом приложения, и pipeline, обрабатывающий входящие запросы. Всё остальное — добавляемые компоненты.


Часть 2. ASP.NET Core: архитектура и хостинг

Ядро: Kestrel

Kestrel — это встроенный, кроссплатформенный, асинхронный веб-сервер на базе System.IO.Pipelines и System.Threading.Channels. Он является обязательной частью любого ASP.NET Core-приложения, даже если оно развёрнуто за IIS или Nginx.

Kestrel не позиционируется как полноценный edge-сервер для публичного доступа (хотя с .NET 7+ его безопасность и производительность позволяют использовать его напрямую в некоторых сценариях). Его основная задача — обеспечить единый контракт выполнения между приложением и хост-окружением. Независимо от того, запущено ли приложение в облаке, на локальной машине или в контейнере, Kestrel предоставляет один и тот же интерфейс: он принимает TCP-соединения, парсит HTTP/1.1 или HTTP/2, формирует объект HttpContext и передаёт его в pipeline.

Важно: Kestrel всегда работает как самостоятельный процесс. Даже при развёртывании в IIS он запускается внутри w3wp.exe, но не встраивается в него как модуль — он живёт как отдельный managed-поток, управляемый хостом.

Варианты развёртывания

ASP.NET Core поддерживает три основные модели хостинга, различающиеся по способу запуска и управлению процессом.

  1. Standalone (self-hosted)
    Приложение компилируется как исполняемый файл (.exe на Windows, без расширения на Linux), который сам запускает Kestrel. Это реализуется через WebApplication.CreateBuilder().Build().Run(). Такой процесс может быть запущен напрямую из командной строки, через systemd (Linux), launchd (macOS) или Task Scheduler (Windows). Для production-развёртывания обычно используется reverse proxy (Nginx, Apache, HAProxy), который:

    • принимает внешние запросы,
    • обрабатывает TLS/SSL,
    • балансирует нагрузку,
    • защищает от DDoS и медленных клиентов,
    • передаёт запросы Kestrel по локальному сокету или HTTP.

    Это стандартный подход для Linux-хостинга и облачных сред (Azure App Service в Linux-режиме, AWS ECS, Kubernetes).

  2. Windows Service / Linux Daemon
    Для long-running background-приложений (например, внутренние API, интеграционные шлюзы), не требующих веб-интерфейса, ASP.NET Core позволяет зарегистрировать приложение как системную службу.

    • На Windows используется WindowsServiceLifetime, и приложение устанавливается через sc create.
    • На Linux — через systemd unit-файл с типом Type=notify и ExecStart=/path/to/app.
      Такой подход даёт автоматический перезапуск при падении, управление зависимостями («запускать после сети»), журналирование через системные журналы (journald, Event Log).
      Kestrel в этом режиме может быть отключён (webBuilder.UseKestrel() => webBuilder.ConfigureKestrel(options => options.ListenLocalhost(0)) или вообще не вызываться), если приложение не слушает HTTP-порты.
  3. Интеграция с IIS (In-Process и Out-of-Process)

IIS остаётся важной платформой для enterprise-развёртываний на Windows. ASP.NET Core поддерживает два режима работы под IIS:

  • Out-of-Process (режим по умолчанию до .NET Core 3.0)
    IIS выступает как reverse proxy. Модуль ASP.NET Core Module (ANCM) перехватывает запрос, запускает отдельный процесс dotnet.exe, в котором работает Kestrel, и перенаправляет трафик через named pipe. Процесс управляется IIS: запускается при первом запросе, останавливается при простое.
    Преимущества: изоляция, совместимость со старыми IIS-модулями.
    Недостатки: накладные расходы на межпроцессное взаимодействие.

  • In-Process (начиная с .NET Core 3.0)
    ANCM загружает .NET Core Runtime непосредственно в рабочий процесс IIS (w3wp.exe). Kestrel не используется — вместо него ASP.NET Core использует IIS HTTP Server, который напрямую взаимодействует с HTTP.sys через IIS.
    Это даёт до 2–3× прирост производительности за счёт устранения IPC, но:

    • приложение должно быть framework-dependent (не self-contained),
    • требуется совместимость с модулями IIS (например, URL Rewrite работает, но некоторые legacy-модули — нет),
    • все приложения в Application Pool должны использовать одну и ту же версию .NET.

Как это работает на уровне Windows?

Когда IIS принимает HTTP-запрос, он не обрабатывает его напрямую. За это отвечает Windows Process Activation Service (WAS) — ядро хостинга, появившееся в IIS 7. WAS управляет жизненным циклом Application Pools и рабочих процессов.

  • World Wide Web Publishing Service (WWW Service) — это компонент WAS, отвечающий именно за HTTP/HTTPS. Он читает конфигурацию из applicationHost.config (глобальный файл настройки IIS, обычно в %windir%\System32\inetsrv\config), создаёт Application Pools и привязывает их к сайтам.
  • Каждый Application Pool — это изолированное окружение, в котором работает один или несколько сайтов. У Pool’а есть свои настройки: версия .NET, режим pipeline (Integrated/Classic), учётная запись, limits (CPU, memory, requests).
  • Рабочий процесс — это w3wp.exe (Worker Process). Каждый Pool может иметь один или несколько таких процессов (Web Garden). w3wp.exe загружает модули IIS, включая aspnetcorev2_inprocess.dll (для In-Process) или aspnetcorev2_outofprocess.dll (для Out-of-Process).
  • svchost.exe — это не часть IIS, а общий хост для Windows-сервисов. WAS и WWW Service работают внутри svchost.exe (можно увидеть в Process Explorer: svchost.exe -k iissvcs). Это важно понимать при диагностике: сбой в WAS может повлиять на все сайты сервера.

Типы развёртывания: self-contained vs framework-dependent

  • Framework-dependent deployment (FDD)
    Приложение компилируется только в IL-код и метаданные. Для запуска требуется предустановленный .NET runtime той же (или совместимой) версии. Это уменьшает размер дистрибутива, упрощает обновление runtime, но требует контроля над окружением. Используется в IIS In-Process, в shared hosting и при централизованном управлении.

  • Self-contained deployment (SCD)
    Приложение поставляется вместе со всей необходимой частью .NET runtime. Это позволяет запускать его на «чистой» машине, использовать специфическую версию (например, с патчем), изолироваться от глобальных обновлений. Размер увеличивается на ~100–150 МБ, но зато нет внешних зависимостей. Используется в standalone-режиме, в контейнерах, в offline-средах.

Модели хостинга: shared, on-premise, cloud

  • Shared hosting — устаревшая модель, где множество сайтов делят один Application Pool и ресурсы сервера. В ASP.NET Core почти не используется: нарушает изоляцию, мешает настройке, несовместима с SCD.

  • On-premise — развёртывание в собственном дата-центре. Здесь возможны все варианты: IIS (In/Out-of-Process), Windows Service с reverse-proxy, bare-metal Kestrel. Требует управления инфраструктурой, но даёт полный контроль.

  • Cloud — абстрагированная среда (Azure App Service, AWS Elastic Beanstalk, Google Cloud Run). Платформа управляет масштабированием, балансировкой, обновлениями. В Azure App Service, например, Linux-вариант использует standalone + Docker + Nginx, Windows-вариант — IIS In-Process. Cloud-провайдеры предоставляют встроенные health-checks, logging, monitoring — но требуют адаптации приложения (например, stateless-дизайн, externalised config).


Часть 3. Конвейер обработки HTTP-запроса

HttpContext: контекст жизни запроса

Каждый HTTP-запрос, поступающий в Kestrel, инкапсулируется в объект Microsoft.AspNetCore.Http.HttpContext. Он — единственный источник истины о текущем запросе и ответе. Именно через него middleware и компоненты приложения взаимодействуют с клиентом.

HttpContext не является простым DTO. Это активный объект, связывающий:

  • входящий запрос (HttpRequest: метод, URL, заголовки, тело, куки),
  • исходящий ответ (HttpResponse: статус, заголовки, тело),
  • состояние аутентификации (User, AuthenticationFeature),
  • сессию (Items — dictionary для передачи данных между middleware),
  • DI-контейнер текущего request scope (RequestServices),
  • вспомогательные сервисы (Features — расширяемая коллекция интерфейсов, например, IHttpConnectionFeature, IHttpRequestIdentifierFeature).

Важно: HttpContext создаётся один раз на запрос и уничтожается после отправки ответа. Его нельзя хранить в статических полях, кэшировать или передавать в фоновые потоки — это приведёт к неопределённому поведению или утечкам памяти. Для асинхронной обработки следует использовать RequestDelegate или IHostedService с явной передачей данных.

Middleware: компоненты конвейера

Middleware (промежуточное ПО) — это функция, которая получает HttpContext, может выполнить произвольную логику до и после передачи управления следующему middleware, и, при необходимости, прервать цепочку.

Формально middleware — это делегат вида RequestDelegate, но на практике реализуется как класс с методом Invoke или InvokeAsync, принимающим HttpContext и RequestDelegate next. Порядок регистрации middleware в Program.cs определяет порядок их выполнения — это не деталь реализации, а архитектурное решение.

Три ключевых метода расширения для построения pipeline:

  • app.Use(Func<HttpContext, Func<Task>, Task> middleware)
    Регистрирует не терминальный middleware. Он всегда вызывает next(), передавая управление дальше, и может выполнять код как до, так и после этого вызова. Пример — логирование:

    app.Use(async (context, next) =>
    {
    var start = Stopwatch.GetTimestamp();
    await next(); // выполнение следующих middleware и конечной точки
    var duration = Stopwatch.GetElapsedTime(start);
    logger.LogRequest(context.Request.Path, duration);
    });

    Такой middleware формирует «обёртку» (wrapper), подобную try-finally.

  • app.Run(Func<HttpContext, Task> middleware)
    Регистрирует терминальный middleware. Он не вызывает next() — вместо этого сам формирует ответ и завершает pipeline. Пример — fallback-обработчик:

    app.Run(context =>
    {
    context.Response.StatusCode = 404;
    return context.Response.WriteAsync("Not Found");
    });

    После Run ничего не выполняется. Поэтому Run обычно ставится в самый конец.

  • app.Map(string pathMatch, Action<IApplicationBuilder> configuration)
    Условно-ветвящий middleware. Если путь запроса начинается с pathMatch, создаётся вложенный pipeline, в котором выполняются middleware из configuration. Это позволяет изолировать логику для отдельных подсистем (например, /api vs /admin).
    Пример:

    app.Map("/health", healthApp =>
    {
    healthApp.Run(context => context.Response.WriteAsync("OK"));
    });

    Внутри healthApp можно использовать Use, Run, Map — это полноценный IApplicationBuilder.

Существуют также MapWhen (ветвление по условию, например, context.Request.Headers.ContainsKey("X-Internal")) и UseWhen (условное подключение middleware без ветвления всего pipeline).

Принцип работы конвейера

Pipeline — это линейная цепочка вызовов, реализованная через композицию делегатов. При вызове app.Build() фреймворк рекурсивно оборачивает каждый middleware в замыкание, где next указывает на следующий в цепочке.

Логически выполнение выглядит так:

[Client]
↓ HTTP Request
[Kestrel] → создаёт HttpContext

[Middleware 1] → Before next()

[Middleware 2] → Before next()

...

[Middleware N] → Before next()

[Endpoint] → выполнение контроллера / Razor Page / делегата

[Middleware N] → After next()

...

[Middleware 2] → After next()

[Middleware 1] → After next()

[Kestrel] → отправка ответа

[Client]

Если какой-либо middleware не вызывает next(), цепочка прерывается, и управление возвращается вверх по стеку — только те middleware, которые уже вызвали next(), выполнят свой «after»-код. Это позволяет реализовывать short-circuiting: например, middleware аутентификации может сразу вернуть 401, не передавая запрос дальше.

Семантика порядка middleware

Порядок критичен. Вот рекомендуемая последовательность (с обоснованием):

  1. UseExceptionHandler / UseDeveloperExceptionPage
    Самый первый — чтобы перехватывать исключения на всех уровнях.

  2. UseHttpsRedirection
    Раннее перенаправление с HTTP на HTTPS — до любой бизнес-логики.

  3. UseStaticFiles
    Обслуживание wwwroot — если запрос совпадает с файлом, pipeline завершается здесь (это Run внутри).

  4. UseRouting
    Не обрабатывает запрос, а только определяет endpoint. После него становятся доступны данные маршрутизации (context.GetEndpoint()), но endpoint ещё не вызван.

  5. UseAuthentication
    Определяет context.User — должен быть до авторизации и бизнес-логики.

  6. UseAuthorization
    Проверяет права на основе User и политик — после аутентификации, до вызова endpoint.

  7. UseSession
    Требует User для привязки сессии — после авторизации.

  8. UseEndpoints / MapRazorPages / MapControllers
    Вызывает endpoint (контроллер, страницу и т.д.). Это терминальный middleware для основного потока.

  9. Run (fallback)
    Обработка 404 — в самом конце.

Нарушение этого порядка ведёт к ошибкам: например, если UseAuthorization поставить до UseAuthentication, User будет null, и все проверки провалятся.

Middleware vs Filter

Часто возникает путаница между middleware и фильтрами (MVC Filters, Razor Pages Filters).

  • Middleware работает на уровне всего приложения, вне зависимости от того, какой endpoint вызван. Он получает «голый» HttpContext. Подходит для кросс-функциональных задач: логирование, CORS, сжатие, обработка ошибок.

  • Фильтры привязаны к конкретной модели разработки (MVC или Razor Pages). Они получают уже связанный с endpoint контекст: ActionExecutingContext, PageHandlerExecutingContext и т.д. Это даёт доступ к:

    • параметрам действия,
    • результату выполнения,
    • ModelState,
    • DI-зависимостям контроллера.
      Фильтры выполняются внутри endpoint-обработчика, после маршрутизации, но до/после вызова метода.

Таким образом, middleware — внешняя оболочка, фильтры — внутренняя инструментовка. Они дополняют друг друга.

Сравнение с OWIN

OWIN (Open Web Interface for .NET) — спецификация 2010-х годов, определявшая минимальный контракт между веб-сервером и приложением: Func<IDictionary<string, object>, Task>. ASP.NET Core изначально планировался как реализация OWIN, но в итоге от него отошёл.

Почему? OWIN слишком низкоуровнев:

  • Передача данных через IDictionary — нет типизации, легко ошибиться в именах ключей.
  • Нет поддержки DI, конфигурации, логирования «из коробки».
  • Middleware не могли зависеть от порядка инициализации.

ASP.NET Core сохранил идею композиции, но заменил словарь на строго типизированный HttpContext, а OWIN-совместимость вынес в отдельный пакет (Microsoft.AspNetCore.Owin), который оборачивает ASP.NET Core в OWIN-интерфейс — но не наоборот.

Сегодня OWIN используется только для совместимости с legacy-библиотеками (например, некоторыми OAuth-провайдерами). Для нового кода применяется нативный pipeline.


Часть 4. Модели разработки веб-приложений

Model-View-Controller (MVC)

MVC — это архитектурный шаблон, адаптированный для веб. В ASP.NET Core он реализован как полноценная модель разработки с чётким разделением:

  • Model — не только классы данных (DTO, domain entities), но и логика предметной области: валидация, преобразования, взаимодействие с репозиториями. Model отвечает на вопрос «что?» — какие данные нужны и как они связаны.

  • View — строго типизированный шаблон (.cshtml), отвечающий за визуальное представление. View знает только о своей Model (через @model), не содержит бизнес-логики и не обращается к сервисам напрямую. Её задача — преобразовать данные в HTML. Важно: View не управляет состоянием — она реактивна.

  • Controller — координатор. Он принимает HTTP-запрос, извлекает данные (из query, body, route), вызывает сервисы для подготовки Model, выбирает View и передаёт ей данные. Controller отвечает на вопрос «как?» — как обработать запрос, но не «почему?» — это прерогатива сервисов.

Жизненный цикл вызова в MVC:

  1. Middleware UseRouting() сопоставляет URL с шаблоном маршрута (например, {controller=Home}/{action=Index}/{id?}).
  2. Middleware UseEndpoints() создаёт экземпляр контроллера через DI.
  3. Model Binding заполняет параметры действия (action method) из источников:
    • [FromRoute] — из сегментов URL (/product/123id = 123),
    • [FromQuery] — из строки запроса (?page=2),
    • [FromBody] — из тела (JSON/XML),
    • [FromForm] — из multipart/form-data.
  4. Выполняются Action Filters (например, [Authorize], [ValidateModel]).
  5. Вызывается метод контроллера (action). Он может:
    • вернуть View() с Model (рендеринг страницы),
    • вернуть Json(), Ok(), CreatedAtAction() (Web API-стиль),
    • перенаправить (RedirectToAction()).
  6. Если возвращён ViewResult, запускается View Engine (Razor), который компилирует .cshtml в C#-код, выполняет его и генерирует HTML.
  7. Выполняются Result Filters (например, кэширование ответа).

Когда применять MVC?
— Когда нужно гибкое управление маршрутизацией (RESTful API + HTML-страницы в одном приложении),
— Когда View и Controller разрабатываются разными командами (фронтенд vs бэкенд),
— Когда требуется сложная композиция UI (View Components, частичные представления),
— В enterprise-приложениях с многоуровневой архитектурой (Presentation → Application → Domain → Infrastructure).


Razor Pages

Razor Pages — это page-centric модель, появившаяся в ASP.NET Core 2.0 как ответ на потребность в более простой альтернативе MVC для CRUD-сценариев и внутренних инструментов (админки, панели управления).

  • Page Model — класс с расширением .cshtml.cs, унаследованный от PageModel. Он объединяет логику обработки и данные для отображения. Свойства помечаются атрибутами привязки ([BindProperty]), методы — OnGet(), OnPost(), OnPostAsync().

  • Page — файл .cshtml, который:

    • обязательно начинается с @page,
    • привязан к Page Model через @model или по соглашению имён,
    • может содержать обработчики в @functions, но это не рекомендуется.

Ключевые отличия от MVC:

КритерийMVCRazor Pages
Единица разработкиКонтроллер + ViewPage Model + Page
МаршрутизацияГлобальные шаблоны или атрибуты на контроллереАвтоматическая по пути файла (например, /Pages/Admin/Users/Index.cshtml/Admin/Users/Index)
СостояниеНет встроенного механизмаModelState, TempData, ViewData работают так же, но проще управлять в рамках одной страницы
СложностьВыше (разделение на 3 части)Ниже (всё в одном месте)
ТестированиеКонтроллеры легко тестируются изолированноPage Model тестируется как обычный класс, но с зависимостью от PageContext

Жизненный цикл вызова в Razor Pages:

  1. UseRouting() сопоставляет URL с файлом страницы (по соглашению).
  2. UseEndpoints() создаёт экземпляр Page Model через DI.
  3. Выполняются Page Filters (аналог Action Filters).
  4. Вызывается метод-обработчик (OnGet(), OnPost() и т.д.).
    • Привязка модели происходит автоматически для свойств с [BindProperty] (по умолчанию — только для POST).
    • Можно использовать [BindProperty(SupportsGet = true)] для GET-параметров.
  5. Если метод возвращает Page(), запускается Razor Engine.
  6. Выполняются Result Filters.

Когда применять Razor Pages?
— Для внутренних инструментов (админки, отчёты), где важна скорость разработки,
— Когда страница автономна (редко переиспользуется логика между страницами),
— В образовательных проектах — проще объяснить «страница = файл + код».

Важно: Razor Pages не заменяет MVC. Это альтернатива для других сценариев. Проект может сочетать обе модели: Razor Pages для админки, MVC — для публичного API.


Web API

Web API — это подход к построению HTTP-based интерфейсов, ориентированных на программное потребление (SPA, мобильные приложения, микросервисы). В ASP.NET Core Web API не выделен в отдельный фреймворк — он интегрирован в MVC.

Ключевые признаки Web API-контроллера:

  • Наследуется от ControllerBase (не от Controller, чтобы избежать View-функциональности),
  • Помечен атрибутом [ApiController],
  • Методы возвращают IActionResult (или напрямую объект, который сериализуется в JSON/XML).

Атрибут [ApiController] включает важные соглашения по умолчанию:

  • Автоматическая привязка из тела для сложных типов (без [FromBody]),
  • Автоматический ответ 400 при !ModelState.IsValid,
  • Требование явных атрибутов маршрутизации ([Route], [HttpGet]),
  • Интерпретация параметров как обязательных, если нет ? или значения по умолчанию.

Два стиля построения API:

  1. REST-like (ресурс-ориентированный)
    Основан на концепции ресурса (например, /api/users/123). Операции выражаются через HTTP-методы:

    • GET /users → список,
    • GET /users/123 → получение,
    • POST /users → создание,
    • PUT /users/123 → полное обновление,
    • PATCH /users/123 → частичное обновление,
    • DELETE /users/123 → удаление.
      Преимущества: предсказуемость, кэшируемость (GET), совместимость с инструментами (Swagger, Postman).
      Недостатки: сложно выразить сложные операции («перевести деньги»), требует HATEOAS для навигации.
  2. RPC-like (операция-ориентированный)
    URL выражает действие: /api/transactions/transfer, /api/reports/generate. Тело запроса содержит все параметры.
    Преимущества: естественно для бизнес-операций, легко версионировать.
    Недостатки: нарушает семантику HTTP, труднее кэшировать, менее стандартизировано.

На практике большинство API — гибрид: ресурсы для CRUD, RPC — для сложных операций.

Когда применять Web API?
— При создании бэкенда для SPA (React, Angular, Vue),
— При построении микросервисов,
— Для интеграции с внешними системами (B2B API),
— В сценариях, где клиент управляет UI (а сервер — только данными и логикой).


ASP.NET Web Forms: архитектурное наследие

Web Forms (2002) — это модель, имитирующая событийное программирование Windows Forms в вебе. Её ключевые концепции существенно отличаются от современных подходов и требуют отдельного объяснения — не для использования, а для сопровождения legacy-систем.

  • Страница (Page) — это класс, унаследованный от System.Web.UI.Page. Каждый .aspx-файл компилируется в такой класс при первом запросе.

  • Жизненный цикл страницы — строго определённая последовательность событий, через которые проходит Page при обработке запроса:
    PreInit → Init → InitComplete → LoadViewState → LoadPostData → PreLoad → Load → LoadComplete → PreRender → PreRenderComplete → SaveStateComplete → Render → Unload.
    Разработчик может подписаться на любое событие и выполнять код. Например, инициализация динамических контролов — в Init, привязка данных — в Load, финальные правки — в PreRender.

  • PostBack — механизм отправки формы на ту же страницу. При нажатии кнопки (<asp:Button>) генерируется JavaScript-вызов __doPostBack(), который отправляет POST-запрос с данными формы и __VIEWSTATE.
    Это позволяет сохранять состояние элементов управления без перезагрузки всей страницы (в связке с UpdatePanel — частичный PostBack через AJAX).

  • ViewState — механизм сериализации состояния контролов в скрытое поле __VIEWSTATE. При PostBack сервер десериализует его и восстанавливает состояние (значения TextBox, выбор в DropDownList и т.д.). Это скрывает stateless-природу HTTP, но увеличивает объём трафика и уязвим к подделке (требует machineKey для защиты).

  • Дерево элементов управления (Control Tree) — иерархическая структура, где каждый элемент (Label, Button, GridView) — это объект с собственным жизненным циклом. При рендеринге каждый контрол вызывает Render(), формируя HTML.

Почему Web Forms устарел?
— Сложность тестирования (сильная связность с HttpContext),
— Неэффективность (избыточный ViewState, тяжёлый HTML),
— Отсутствие контроля над генерируемым HTML,
— Несовместимость с современными фронтенд-фреймворками,
— Зависимость от IIS и Windows.

Однако многие enterprise-системы (1C, SAP, внутренние ERP) до сих пор используют Web Forms — поэтому понимание его архитектуры необходимо для миграции или интеграции.


Blazor: SPA на .NET

Blazor — фреймворк для создания одностраничных приложений (SPA) с использованием C# и Razor, без JavaScript. Он существует в двух режимах хостинга:

  1. Blazor Server

    • UI-логика (обработчики событий, компоненты) выполняется на сервере,
    • Взаимодействие с браузером — через SignalR-соединение (WebSockets или long polling),
    • Состояние компонентов хранится на сервере (в памяти),
    • Преимущества: высокая производительность сервера, доступ к полному .NET API,
    • Недостатки: задержки при высокой latency, масштабирование требует sticky sessions, уязвимость к потере соединения.
  2. Blazor WebAssembly (WASM)

    • Вся логика компилируется в WebAssembly и выполняется в браузере,
    • Для доступа к данным — HTTP-запросы к API (обычно ASP.NET Core Web API),
    • Преимущества: offline-возможности, масштабирование (статический хостинг),
    • Недостатки: начальная загрузка (2–5 МБ), ограничения WASM (нет доступа к файловой системе, ограниченный .NET API), сложность отладки.

Компонентная модель Blazor:
— Компонент — это класс с расширением .razor, содержащий HTML-разметку и C#-логику,
— Взаимодействие через параметры ([Parameter]) и события (EventCallback),
— Жизненный цикл: OnInitialized, OnParametersSet, OnAfterRender,
— Состояние управляется через StateHasChanged() или INotifyPropertyChanged.

Когда применять Blazor?
— Blazor Server — для внутренних инструментов с контролируемой сетью (офисные приложения),
— Blazor WASM — для public-приложений, где важна клиентская производительность после загрузки,
— В командах, где нет экспертизы по JavaScript, но есть сильные .NET-разработчики.

WebAssembly (WASM) — бинарный формат для выполнения кода в браузере. Он не заменяет JavaScript, а дополняет его: WASM-модуль может вызывать JS и наоборот. В .NET WASM-файл содержит IL-код, который интерпретируется Mono runtime, скомпилированным в WASM.


Часть 5. Маршрутизация

Концепция endpoint

Endpoint — это не URL, а логическая точка входа в приложение: метод контроллера, Razor Page, делегат RequestDelegate, gRPC-сервис. Каждый endpoint имеет:

  • Шаблон маршрута (например, api/[controller]/[action]),
  • Метаданные (атрибуты: [Authorize], [ResponseCache]),
  • Делегат обработки (RequestDelegate).

Маршрутизация в ASP.NET Core разделяется на два этапа:

  1. Регистрация endpoint’ов — происходит при запуске приложения (в Program.cs).
    Например:

    endpoints.MapControllers(); // регистрирует все ApiController’ы
    endpoints.MapRazorPages(); // регистрирует все Razor Pages
    endpoints.MapGet("/ping", () => "OK"); // регистрирует делегат
  2. Сопоставление запроса с endpoint’ом — происходит на каждом запросе в middleware UseRouting().
    Оно не вызывает endpoint, а только определяет, какой endpoint соответствует запросу, и сохраняет его в HttpContext.GetEndpoint().

Только после этого middleware UseEndpoints() (или Map*) вызывает делегат endpoint’а.

Такое разделение позволяет middleware, зарегистрированным между UseRouting() и UseEndpoints(), получать доступ к метаданным endpoint’а (например, проверить, требует ли он авторизации), не запуская его логику.

Соглашения vs атрибуты

ASP.NET Core поддерживает два способа определения маршрутов:

  1. Маршрутизация на основе соглашений
    Глобальные шаблоны, задаваемые при регистрации endpoint’ов. Пример:

    endpoints.MapControllerRoute(
    name: "default",
    pattern: "{controller=Home}/{action=Index}/{id?}"
    );
    • controller, action, id — параметры маршрута,
    • Home, Index — значения по умолчанию,
    • ? — параметр необязательный.

    Преимущества: единообразие, централизованное управление.
    Недостатки: сложно выразить сложные сценарии (версионирование, многоязычные URL).

  2. Маршрутизация на основе атрибутов
    Маршруты задаются непосредственно на контроллерах и методах:

    [ApiController]
    [Route("api/v{version:apiVersion}/[controller]")] // шаблон на уровне контроллера
    public class ProductsController : ControllerBase
    {
    [HttpGet("{id:int}")] // шаблон на уровне метода
    public IActionResult Get(int id) { ... }
    }
    • [controller], [action] — токены, заменяющиеся на имя класса/метода,
    • {id:int} — параметр с ограничением (только целые числа),
    • v{version:apiVersion} — параметр с кастомным ограничением (ApiVersionConstraint).

    Преимущества: точный контроль, поддержка REST, версионирование.
    Недостатки: дублирование, сложность аудита всех маршрутов.

В реальных проектах часто используется гибрид: соглашения для базовой структуры, атрибуты — для специфичных случаев.

Параметры маршрутов и ограничения

Параметры маршрута — это сегменты URL, заключённые в фигурные скобки: {parameter}. Они могут иметь:

  • Значения по умолчанию: {id=0} или через = defaultValue в шаблоне,
  • Необязательность: {id?} — сегмент может отсутствовать,
  • Ограничения (constraints) — встроенные или кастомные правила валидации.

Встроенные ограничения:

  • int, long, guid, datetime — типы,
  • min(1), max(100), range(1,100) — числовые диапазоны,
  • alpha, regex(...), length(5,10) — строковые правила,
  • booltrue/false,
  • apiVersion — для библиотеки Microsoft.AspNetCore.Mvc.Versioning.

Пример с несколькими ограничениями:

[HttpGet("report/{year:int:min(2000):max(2050)}/{month:range(1,12)}")]
public IActionResult GetReport(int year, int month) { ... }

Кастомные ограничения реализуются через IRouteConstraint и регистрируются в DI:

builder.Services.Configure<RouteOptions>(options =>
{
options.ConstraintMap.Add("slug", typeof(SlugConstraint));
});

public class SlugConstraint : IRouteConstraint
{
public bool Match(HttpContext httpContext, IRouter route, string routeKey,
RouteValueDictionary values, RouteDirection routeDirection)
{
var value = values[routeKey]?.ToString();
return value != null && Regex.IsMatch(value, @"^[a-z0-9\-]+$");
}
}

Route-to-code binding

Процесс превращения URL в вызов метода включает:

  1. Сопоставление шаблона
    URL /api/products/123 сопоставляется с шаблоном api/[controller]/{id}controller = "Products", id = "123".

  2. Поиск контроллера
    По соглашению ищется класс ProductsController (суффикс Controller добавляется автоматически).

  3. Поиск метода (action)

    • Сопоставление по HTTP-методу ([HttpGet]),
    • Сопоставление по имени (если шаблон содержит [action], иначе — по соглашению: Get, Post, GetById и т.д.),
    • Сопоставление по параметрам (количество и имена должны совпадать с параметрами метода или route/query).
  4. Привязка модели (Model Binding)
    Значения параметров маршрута (id = "123") передаются в параметры метода. Если тип не совпадает (например, int id), вызывается TypeConverter или ModelBinder.

Если ни один endpoint не найден — возвращается 404. Если найдено несколько — возникает неоднозначность (ambiguous match), и приложение не запустится (ошибка на этапе регистрации).

Расширяемость маршрутизации

ASP.NET Core позволяет создавать кастомные endpoint’ы и маршруты:

  • Кастомные endpoint builders
    Например, MapHealthChecks() — это extension method, который регистрирует endpoint с делегатом проверки здоровья.

  • Кастомные маршрутизаторы (IRouter)
    Реализация IRouter даёт полный контроль над логикой сопоставления. Используется редко (например, для legacy-совместимости).

  • Dynamic endpoint registration
    Можно регистрировать endpoint’ы динамически при обработке запроса (например, для CMS с URL из БД):

    app.MapDynamicControllerRoute<CustomTransformer>("/content/{**slug}");

    где CustomTransformer реализует IDynamicEndpointTransformer.

Важно: динамическая маршрутизация снижает производительность (сопоставление на каждый запрос), поэтому для статических маршрутов предпочтительна статическая регистрация при старте приложения.


Часть 6. Шаблонизация и представления

Razor Engine: от шаблона к коду

Razor — это язык шаблонов, позволяющий встраивать C#-код в HTML-подобную разметку. Его ключевая особенность — контекстно-зависимый парсинг: Razor различает HTML-контекст и код-контекст на основе символов (@, {, }, ;), что делает синтаксис лаконичным.

Но важно понимать: Razor — это не интерпретатор. Это компилятор, который на этапе сборки (или при первом запросе) преобразует .cshtml-файл в C#-класс, унаследованный от RazorPage<TModel>. Например, для Index.cshtml генерируется класс Index, содержащий метод ExecuteAsync(), в котором:

  • HTML-литералы → вызовы WriteLiteral(),
  • @model.Name → вызовы Write(Model.Name),
  • @{ ... } → встраивание C#-блока.

Этот класс компилируется в сборку (обычно в App.dll при публикации с RazorCompileOnPublish=true), что обеспечивает:

  • Типовую безопасность — ошибки в шаблоне обнаруживаются на этапе компиляции,
  • Высокую производительность — нет парсинга шаблона при каждом запросе,
  • Поддержку отладки — можно ставить точки останова в .cshtml.

В режиме разработки (Development) Razor поддерживает динамическую компиляцию: при изменении .cshtml файл перекомпилируется «на лету», без перезапуска приложения.

Безопасность шаблонов: auto-encoding и XSS

По умолчанию Razor автоматически экранирует весь вывод через @:

<p>@userInput</p> <!-- userInput = "<script>alert(1)</script>" -->
<!-- Результат: &lt;script&gt;alert(1)&lt;/script&gt; -->

Это предотвращает XSS-атаки. Экранирование применяется к string, object, HtmlString (если не помечен как доверенный).

Для вывода недоверенного HTML используется Html.Raw():

@Html.Raw(Model.TrustedHtml) <!-- Только если HTML прошёл санитизацию! -->

Но это опасная операция — её следует применять только к данным, прошедшим строгую валидацию и очистку (например, через библиотеку HtmlSanitizer).

Композиция UI: уровни переиспользования

ASP.NET Core предоставляет иерархию механизмов для повторного использования разметки — от простых фрагментов до полноценных компонентов.

  1. Layout (макет)
    Файл _Layout.cshtml задаёт общую структуру страницы:

    <!DOCTYPE html>
    <html>
    <head>...</head>
    <body>
    <header>...</header>
    <main>
    @RenderBody() <!-- Содержимое конкретной страницы -->
    </main>
    <footer>...</footer>
    @RenderSection("Scripts", required: false) <!-- Опциональные скрипты -->
    </body>
    </html>

    Страница указывает layout через:

    @{
    Layout = "_Layout";
    }

    или глобально в _ViewStart.cshtml (см. ниже).

  2. _ViewStart.cshtml
    Специальный файл, выполняемый перед каждой View или Page. Обычно используется для задания общего Layout:

    @{
    Layout = "_Layout";
    }

    Располагается в папке Views/ (для MVC) или Pages/ (для Razor Pages). Иерархия: _ViewStart в подпапке переопределяет родительский.

  3. Partial Views (частичные представления)
    Фрагменты разметки без логики (_ProductCard.cshtml), включаемые в другие страницы:

    <partial name="_ProductCard" model="Model.Product" />
    <!-- Или через HTML-хелпер: @Html.Partial("_ProductCard", Model.Product) -->

    Подходят для простых, статичных блоков (карточки, формы). Не имеют собственного Page Model.

  4. View Components
    Это полноправные компоненты с логикой и представлением. Состоят из:

    • Класса, унаследованного от ViewComponent, с методом InvokeAsync() или Invoke(),
    • Представления Default.cshtml в папке Views/Shared/Components/ComponentName/.

    Пример:

    public class PriorityListViewComponent : ViewComponent
    {
    public async Task<IViewComponentResult> InvokeAsync(int maxPriority)
    {
    var items = await GetItemsAsync(maxPriority);
    return View(items);
    }
    }

    Вызов в шаблоне:

    @await Component.InvokeAsync("PriorityList", new { maxPriority = 3 })
    <!-- Или через Tag Helper: <vc:priority-list max-priority="3" /> -->

    Преимущества перед Partial Views:
    — Поддержка DI (можно принимать сервисы в конструкторе),
    — Асинхронность,
    — Тестирование как обычного класса.

Tag Helpers: серверная логика в HTML

Tag Helpers — это компоненты, расширяющие HTML-теги декларативной серверной логикой. В отличие от HTML-хелперов (например, @Html.ActionLink()), они не нарушают читаемость разметки.

Примеры встроенных Tag Helper’ов:

  • <a asp-controller="Home" asp-action="Index">
    Генерирует <a href="/Home/Index">, автоматически учитывая маршруты и параметры.

  • <form asp-action="Submit" method="post"> Добавляет action="/Controller/Submit", AntiForgeryToken, управляет методом.

  • <input asp-for="Email" /> Генерирует <input name="Email" id="Email" value="@Model.Email" />, с поддержкой DataAnnotations (валидация, типы).

  • <environment include="Development">
    Условный рендеринг для окружений.

Создание кастомного Tag Helper’а:

  1. Класс, унаследованный от TagHelper, с атрибутом [HtmlTargetElement]:

    [HtmlTargetElement("email", Attributes = "address")]
    public class EmailTagHelper : TagHelper
    {
    public string Address { get; set; } = string.Empty;

    public override void Process(TagHelperContext context, TagHelperOutput output)
    {
    output.TagName = "a";
    output.Attributes.SetAttribute("href", $"mailto:{Address}");
    output.Content.SetContent(Address);
    }
    }
  2. Регистрация в _ViewImports.cshtml:

    @addTagHelper *, MyApp
  3. Использование:

    <email address="support@example.com" />
    <!-- Результат: <a href="mailto:support@example.com">support@example.com</a> -->

Преимущества Tag Helpers:

  • Сохраняют HTML-подобный синтаксис,
  • Поддерживаются инструментами (IntelliSense, валидация в IDE),
  • Изолированы — не влияют на другие теги.

Кэширование шаблонов

Для повышения производительности ASP.NET Core кэширует:

  • Скомпилированные типы страниц (в памяти),
  • Результаты рендеринга (если используется [ResponseCache] или IMemoryCache).

Кэш компиляции сбрасывается при изменении .cshtml (в режиме разработки) или при перезапуске приложения. В production-сборках шаблоны компилируются статически, и кэш не требуется.

Для динамического контента (например, новостная лента) применяется фрагментное кэширование через Tag Helper <cache>:

<cache expires-after="@TimeSpan.FromMinutes(10)">
<partial name="_NewsFeed" model="Model.News" />
</cache>

Кэш учитывает параметры (например, vary-by-user), чтобы разные пользователи видели своё содержимое.


Часть 7. Конфигурация и управление параметрами

Единая модель IConfiguration

Корень всей конфигурации — интерфейс Microsoft.Extensions.Configuration.IConfiguration. Он предоставляет:

  • Иерархический доступ через ключи с разделителем : (например, "Database:ConnectionString"),
  • Единообразное API для чтения значений (GetSection, GetValue<T>),
  • Ленивую загрузку — значения считываются из источника только при первом обращении.

IConfiguration строится как стек поставщиков (providers). Каждый поставщик — это реализация IConfigurationProvider, которая загружает данные из конкретного источника. При чтении значения фреймворк проходит по стеку снизу вверх и возвращает первое найденное значение — это позволяет переопределять настройки более приоритетными источниками.

Поставщики конфигурации и их приоритеты

Стандартные поставщики (в порядке регистрации, т.е. увеличения приоритета):

  1. Файлы (appsettings.json, appsettings.{Environment}.json)
    Основной источник. Поддерживает вложенность через JSON-объекты:

    {
    "Database": {
    "ConnectionString": "Server=...",
    "Timeout": 30
    }
    }

    Файл окружения (например, appsettings.Production.json) переопределяет значения из базового файла.

  2. Переменные среды
    Ключи преобразуются: __ заменяется на : (например, Database__ConnectionString). Это позволяет задавать настройки в Docker, Kubernetes, Azure App Settings без изменения кода.

  3. Аргументы командной строки
    Формат: --key=value или /key value. Используется для переопределения в CI/CD или при локальном запуске.

  4. Пользовательские секреты (User Secrets)
    Только в Development. Хранит чувствительные данные (пароли, ключи) в зашифрованном файле вне репозитория (%APPDATA%\Microsoft\UserSecrets\<id>\secrets.json). Активируется через AddUserSecrets<Program>().

  5. Azure Key Vault, Consul, etcd
    Через сторонние пакеты (Azure.Extensions.AspNetCore.Configuration.Secrets). Для production-секретов.

Порядок регистрации в Program.cs определяет приоритет:

var builder = WebApplication.CreateBuilder(args);

builder.Configuration
.AddJsonFile("appsettings.json", optional: true)
.AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true)
.AddEnvironmentVariables()
.AddCommandLine(args);

Здесь CommandLine имеет наивысший приоритет — его значения переопределят всё остальное.

Options pattern: типизированный доступ к конфигурации

Прямое использование IConfiguration["key"] не рекомендуется: это нарушает типовую безопасность и усложняет тестирование. Вместо этого применяется Options pattern — привязка конфигурации к POCO-классам.

  1. Определение класса настроек:

    public class DatabaseOptions
    {
    public string ConnectionString { get; set; } = string.Empty;
    public int Timeout { get; set; } = 30;
    }
  2. Регистрация в DI:

    builder.Services.Configure<DatabaseOptions>(
    builder.Configuration.GetSection("Database")
    );
  3. Инъекция в компоненты:

    public class MyService
    {
    private readonly DatabaseOptions _options;

    public MyService(IOptions<DatabaseOptions> options)
    {
    _options = options.Value; // получение значения
    }
    }

Три интерфейса для работы с опциями:

  • IOptions<T> — синглтон-значение, загружаемое при старте приложения. Не реагирует на изменения конфигурации.

  • IOptionsSnapshot<T> — создаётся на каждый запрос (scoped). Подходит для сценариев, где настройки могут меняться между запросами (например, multi-tenant).

  • IOptionsMonitor<T> — позволяет подписаться на изменения конфигурации:

    _monitor.OnChange(options => 
    {
    logger.LogInformation("Database timeout changed to {Timeout}", options.Timeout);
    });

    Требует поддержки reload’а от поставщика (например, AddJsonFile(..., reloadOnChange: true)).

Валидация конфигурации

Options pattern поддерживает валидацию через Data Annotations и кастомные правила:

  1. Атрибуты валидации:

    public class DatabaseOptions
    {
    [Required]
    public string ConnectionString { get; set; } = string.Empty;

    [Range(1, 300)]
    public int Timeout { get; set; } = 30;
    }
  2. Регистрация с валидацией:

    builder.Services.AddOptions<DatabaseOptions>()
    .BindConfiguration("Database")
    .ValidateDataAnnotations()
    .Validate(options => options.Timeout > 0, "Timeout must be positive.");
  3. Проверка при старте:

    var app = builder.Build();
    app.ValidateOptions(); // выбросит исключение при невалидных настройках

Это гарантирует, что приложение не запустится с некорректной конфигурацией.

Создание кастомных поставщиков

Для специфичных источников (например, конфигурация из базы данных, gRPC-сервиса) можно реализовать свой поставщик.

  1. Реализация IConfigurationProvider:

    public class DatabaseConfigurationProvider : ConfigurationProvider
    {
    private readonly IDbConnection _connection;

    public DatabaseConfigurationProvider(IDbConnection connection)
    {
    _connection = connection;
    }

    public override void Load()
    {
    Data = _connection.Query("SELECT Key, Value FROM Config")
    .ToDictionary(x => x.Key, x => x.Value);
    }
    }
  2. Реализация IConfigurationSource:

    public class DatabaseConfigurationSource : IConfigurationSource
    {
    private readonly string _connectionString;

    public DatabaseConfigurationSource(string connectionString)
    {
    _connectionString = connectionString;
    }

    public IConfigurationProvider Build(IConfigurationBuilder builder)
    {
    return new DatabaseConfigurationProvider(
    new SqlConnection(_connectionString)
    );
    }
    }
  3. Метод расширения для удобства:

    public static class ConfigurationExtensions
    {
    public static IConfigurationBuilder AddDatabaseConfiguration(
    this IConfigurationBuilder builder, string connectionString)
    {
    return builder.Add(new DatabaseConfigurationSource(connectionString));
    }
    }
  4. Регистрация:

    builder.Configuration.AddDatabaseConfiguration(
    builder.Configuration["DbConfig:ConnectionString"]
    );

Кастомные поставщики интегрируются в общий стек и участвуют в переопределении значений наравне со встроенными.

Иерархия конфигурации и привязка

Конфигурация поддерживает сложные структуры:

  • Массивы:

    "AllowedHosts": [ "localhost", "example.com" ]

    Привязка к List<string> AllowedHosts.

  • Вложенные объекты:

    "Logging": {
    "LogLevel": {
    "Default": "Information",
    "Microsoft": "Warning"
    }
    }

    Привязка к LoggingOptions с вложенным Dictionary<string, string> LogLevel.

  • Секции как отдельные объекты:

    builder.Services.Configure<SmtpOptions>(
    builder.Configuration.GetSection("Email:Smtp")
    );

Привязка выполняется через Microsoft.Extensions.Configuration.Binder, который использует рефлексию и поддерживает:

  • Простые типы (int, bool, string),
  • Коллекции (List<T>, Dictionary<K,V>),
  • Комплексные объекты (рекурсивно),
  • Кастомные конвертеры (TypeConverter).

Часть 8. Внедрение зависимостей (DI)

Встроенная реализация: возможности и ограничения

ASP.NET Core поставляется с минимальным, но достаточным DI-контейнером. Он поддерживает:

  • Регистрацию сервисов с указанием времени жизни,
  • Разрешение зависимостей через конструктор (constructor injection),
  • Интеграцию с фреймворком (контроллеры, middleware, hosted services автоматически разрешаются из DI),
  • Проверку циклических зависимостей на этапе сборки (при использовании ValidateScopes).

Ограничения встроенного контейнера (по сравнению с Autofac, Lamar, Ninject):

  • Нет поддержки регистрации по соглашению (convention-based registration),
  • Нет декораторов, интерсепторов, property injection,
  • Нет поддержки IEnumerable<T> с разным временем жизни (все элементы будут иметь время жизни самого IEnumerable),
  • Нет поддержки keyed/named dependencies.

Эти ограничения намеренны: они поощряют простые, тестируемые архитектуры. Для сложных сценариев можно подключить сторонний контейнер — фреймворк предоставляет стандартный интерфейс IServiceProviderFactory<T>.

Время жизни сервисов: не определения, а последствия

Выбор времени жизни — это архитектурное решение, влияющее на производительность, изоляцию и потокобезопасность.

  1. Singleton

    • Один экземпляр на всё приложение.
    • Когда использовать: логгеры, утилиты без состояния, кэши-обёртки, глобальные конфигурации.
    • Опасности:
      — Хранение состояния (например, List<T> _items) приведёт к гонкам данных,
      — Зависимость от scoped-сервисов (например, DbContext) вызовет InvalidOperationException при попытке разрешения.
  2. Scoped

    • Один экземпляр на HTTP-запрос (или на сессию в фоновой задаче).
    • Когда использовать:
      DbContext (EF Core требует одного контекста на запрос для отслеживания изменений),
      — Сервисы с состоянием, привязанным к запросу (например, ICurrentUserService),
      — Unit of Work, репозитории в рамках одного запроса.
    • Опасности:
      — Использование в singleton’ах (см. выше),
      — Передача scoped-сервиса в фоновый поток без создания scope’а.
  3. Transient

    • Новый экземпляр при каждом разрешении.
    • Когда использовать:
      — Простые, stateless-сервисы (валидаторы, мапперы, фабрики),
      — Сервисы, которые создают scoped-зависимости внутри себя (например, IServiceScopeFactory.CreateScope()).
    • Опасности:
      — Создание «тяжёлых» объектов (например, подключения к БД) — приведёт к утечкам ресурсов,
      — Неявное создание множества экземпляров при вложенных разрешениях.

Важное уточнение: время жизни определяет жизненный цикл экземпляра, а не время жизни ссылки. DI-контейнер управляет временем жизни только для объектов, созданных им самим. Если вы создаёте экземпляр вручную (new MyService()), контейнер не отслеживает его.

DI в различных контекстах

  1. В контроллерах и Razor Pages
    Зависимости инъектируются через конструктор. Фреймворк автоматически разрешает их из DI:

    public class ProductsController : ControllerBase
    {
    private readonly IProductService _productService;

    public ProductsController(IProductService productService)
    {
    _productService = productService;
    }
    }
  2. В middleware
    Middleware создаётся один раз при старте приложения (не на каждый запрос!), поэтому зависимости нельзя инъектировать в конструктор — они будут «заморожены» как singleton’ы.
    Правильный способ — получать scoped-сервисы через context.RequestServices:

    public class LoggingMiddleware
    {
    private readonly RequestDelegate _next;

    public LoggingMiddleware(RequestDelegate next) // только transient/singleton
    {
    _next = next;
    }

    public async Task InvokeAsync(HttpContext context)
    {
    var logger = context.RequestServices.GetRequiredService<ILogger<LoggingMiddleware>>();
    logger.LogInformation("Request started");
    await _next(context);
    }
    }
  3. В фоновых задачах (IHostedService)
    IHostedService создаётся как singleton. Для выполнения scoped-операций нужно вручную создавать scope:

    public class DataCleanupService : IHostedService
    {
    private readonly IServiceScopeFactory _scopeFactory;

    public DataCleanupService(IServiceScopeFactory scopeFactory)
    {
    _scopeFactory = scopeFactory;
    }

    public async Task StartAsync(CancellationToken ct)
    {
    using var scope = _scopeFactory.CreateScope();
    var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();
    await dbContext.CleanupOldDataAsync(ct);
    }
    }
  4. В SignalR hubs
    Хабы создаются на каждое соединение (de facto scoped). Зависимости инъектируются через конструктор, как в контроллерах.

Интеграция сторонних DI-контейнеров

Для подключения Autofac, Lamar и др. используется IServiceProviderFactory<T>:

var builder = WebApplication.CreateBuilder(args);

// Отключаем валидацию в built-in DI (она не нужна при использовании стороннего контейнера)
builder.Host.UseServiceProviderFactory(new AutofacServiceProviderFactory());

// Регистрация в Autofac
builder.Host.ConfigureContainer<ContainerBuilder>(containerBuilder =>
{
containerBuilder.RegisterModule<MyAutofacModule>();
});

Контейнер создаётся после регистрации всех сервисов через builder.Services, поэтому:

  • Сначала регистрируем через IServiceCollection (стандартный путь),
  • Затем донастраиваем в ConfigureContainer (специфичные фичи контейнера).

Это обеспечивает совместимость с библиотеками, которые регистрируют зависимости через IServiceCollection (например, EF Core, Identity).

Практические рекомендации

  • Избегайте Service Locator (IServiceProvider.GetService<T>() в бизнес-логике). Это скрывает зависимости и усложняет тестирование. Используйте только в middleware и hosted services, где constructor injection невозможен.

  • Не смешивайте времена жизни без необходимости. Если сервис A (scoped) зависит от B (singleton), это допустимо. Но если B (singleton) зависит от A (scoped) — будет исключение.

  • Тестируйте разрешение зависимостей. Используйте Host.CreateDefaultBuilder().Build().Services в интеграционных тестах, чтобы убедиться, что все зависимости разрешаются.

  • Регистрируйте интерфейсы, а не конкретные классы — это упрощает подмену реализаций (например, для моков в тестах).


Часть 9. Работа с данными в веб-приложении

Слои данных и их границы

В правильно спроектированном веб-приложении данные проходят через несколько слоёв, каждый из которых имеет чёткую ответственность:

  1. Transport Layer (HTTP)
    — Входящие данные: query string, route parameters, form fields, JSON/XML тело запроса,
    — Исходящие данные: JSON, XML, HTML, файлы.
    Ответственность: сериализация/десериализация, валидация формата.

  2. Application Layer (контроллеры, страницы)
    — Принимает транспортные объекты (DTO),
    — Оркестрирует вызовы сервисов,
    — Преобразует результаты в транспортные объекты.
    Ответственность: координация, не бизнес-логика.

  3. Domain Layer (сервисы, агрегаты)
    — Содержит бизнес-правила, валидацию предметной области, инварианты,
    — Оперирует доменными моделями (rich domain objects),
    — Не знает о HTTP, DTO, ORM.
    Ответственность: «почему?» — почему операция разрешена/запрещена.

  4. Infrastructure Layer (репозитории, ORM, внешние API)
    — Реализует доступ к данным,
    — Преобразует доменные модели ↔ DTO хранилища,
    — Обеспечивает транзакционность.
    Ответственность: «как?» — как сохранить/загрузить данные.

Ключевые типы объектов и их назначение:

  • Domain Model — классы предметной области с поведением (например, Order.AddLineItem(Product, int quantity)), инкапсулирующие бизнес-правила. Не должны содержать атрибутов сериализации или ORM.

  • DTO (Data Transfer Object) — плоские, неизменяемые объекты для передачи данных между слоями (например, CreateOrderRequest, OrderResponse). Используются в контроллерах и при вызове внешних сервисов.

  • ViewModel — DTO, специфичные для представления (например, ProductDetailsPageModel), содержащие данные, необходимые именно для отрисовки страницы (включая выпадающие списки, флаги видимости и т.д.).

  • Entity (ORM Entity) — классы, отображаемые на таблицы БД (например, OrderEntity). Содержат атрибуты ORM ([Key], [Column]) и могут отличаться от Domain Model (например, содержать технические поля вроде RowVersion).

Разделение этих типов предотвращает утечку абстракций: например, атрибуты валидации [Required] для UI не должны влиять на бизнес-правила в домене.

Model Binding: от HTTP к объекту

Model Binding — механизм автоматического заполнения параметров действия (action method) из источников HTTP-запроса. Он работает до вызова метода контроллера и является частью конвейера MVC.

Источники данных и приоритеты:

  1. Route Values ({id} в шаблоне) — высший приоритет для параметров с совпадающими именами,
  2. Query String (?page=2&size=10),
  3. Form Data (application/x-www-form-urlencoded, multipart/form-data),
  4. Request Body (application/json, application/xml) — только для одного параметра (обычно сложного типа),
  5. Files — через IFormFile.

Управление привязкой через атрибуты:

  • [FromRoute], [FromQuery], [FromForm], [FromBody] — явное указание источника,
  • [BindRequired] — параметр обязателен, иначе ModelState будет invalid,
  • [BindNever] — исключить параметр из привязки (защита от overposting),
  • [ModelBinder(typeof(MyCustomBinder))] — кастомный биндер.

Кастомный Model Binder реализует IModelBinder и регистрируется глобально или через атрибут:

public class SlugBinder : IModelBinder
{
public Task BindModelAsync(ModelBindingContext bindingContext)
{
var value = bindingContext.ValueProvider.GetValue("slug").FirstValue;
if (value != null && SlugHelper.IsValid(value))
{
bindingContext.Result = ModelBindingResult.Success(new Slug(value));
}
return Task.CompletedTask;
}
}

Валидация: ModelState и политики

Валидация происходит после привязки модели и состоит из двух уровней:

  1. Валидация формата (Model State Validation)
    — Проверка типов ("abc"int = ошибка),
    — Проверка атрибутов DataAnnotations ([Required], [StringLength]),
    — Результат сохраняется в ModelState.IsValid.

  2. Бизнес-валидация (Domain Validation)
    — Проверка инвариантов в доменных сервисах (OrderService.CreateOrder() может вернуть ValidationResult),
    — Не зависит от HTTP-контекста.

Глобальные фильтры валидации:
Атрибут [ApiController] автоматически возвращает 400 при !ModelState.IsValid. Это можно настроить через:

builder.Services.Configure<ApiBehaviorOptions>(options =>
{
options.InvalidModelStateResponseFactory = context =>
{
var errors = context.ModelState
.Where(e => e.Value.Errors.Count > 0)
.ToDictionary(
e => e.Key,
e => e.Value.Errors.Select(x => x.ErrorMessage).ToArray()
);
return new BadRequestObjectResult(errors);
};
});

Для сложных сценариев (например, условная валидация) используется IValidatableObject:

public class CreateUserRequest : IValidatableObject
{
public string Password { get; set; } = string.Empty;
public string ConfirmPassword { get; set; } = string.Empty;

public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
{
if (Password != ConfirmPassword)
{
yield return new ValidationResult("Passwords do not match",
new[] { nameof(ConfirmPassword) });
}
}
}

ORM-стек

  1. Entity Framework Core (EF Core) — полноценный ORM с поддержкой:
    — Отслеживания изменений (change tracking),
    — Ленивой загрузки (lazy loading, опционально),
    — Миграций кода первой (code-first migrations),
    — Поддержки отношений (one-to-many, many-to-many),
    — Глобальных фильтров (soft delete),
    — Owned Types (вложенные объекты как часть агрегата).
    Когда использовать: приложения с богатой доменной моделью, где важна продуктивность разработки и поддержка сложных сценариев.

  2. Dapper — микро-ORM, выполняющий только маппинг результатов SQL-запросов в объекты.
    — Высокая производительность (близка к ADO.NET),
    — Полный контроль над SQL,
    — Нет отслеживания изменений — нужно писать UPDATE вручную.
    Когда использовать: high-load read-операции, legacy-БД с неудобной схемой, микросервисы с простыми CRUD-операциями.

  3. ADO.NET — низкоуровневый доступ к БД через SqlConnection, SqlCommand, SqlDataReader.
    — Максимальная производительность и контроль,
    — Требует ручной обработки соединений, параметров, маппинга.
    Когда использовать: критически важные операции (bulk insert), интеграция с нестандартными СУБД, кастомные провайдеры.

Паттерны доступа к данным:

  • Repository Pattern
    Абстрагирует доступ к данным за интерфейсом (IProductRepository), скрывая детали ORM. Позволяет легко подменять реализации (например, для тестов).

    public interface IProductRepository
    {
    Task<Product> GetByIdAsync(int id);
    Task<IEnumerable<Product>> ListAsync();
    Task AddAsync(Product product);
    }
  • Unit of Work
    Обеспечивает атомарность операций над несколькими репозиториями через общую транзакцию. В EF Core это DbContext — он сам является Unit of Work:

    using var transaction = await _context.Database.BeginTransactionAsync();
    try
    {
    await _productRepo.AddAsync(product);
    await _orderRepo.AddAsync(order);
    await _context.SaveChangesAsync();
    await transaction.CommitAsync();
    }
    catch
    {
    await transaction.RollbackAsync();
    throw;
    }
  • CQRS (Command Query Responsibility Segregation)
    Разделение операций на:
    Commands (изменение состояния: CreateOrderCommand),
    Queries (чтение: GetOrderQuery).
    Позволяет оптимизировать каждый путь отдельно (например, использовать разные БД для записи и чтения).

Безопасность при работе с данными

  • Параметризованные запросы — обязательны для предотвращения SQL-инъекций. EF Core и Dapper делают это автоматически при использовании ExecuteAsync(sql, param).
  • Ограничение overposting — использование [BindNever], BindProperty в Razor Pages, DTO вместо domain models в контроллерах.
  • Пагинация — никогда не возвращать IQueryable<T> из сервисов; всегда применять Skip/Take на уровне репозитория.
  • Фильтрация на уровне БД — избегать .ToList().Where(...) для больших таблиц.